If you’re like me, then you probably take application caching and performance improvements for granted because you rely on a web framework like Rails to do this for you. Although this level of abstraction is helpful, it can also obscure the underlying mechanisms, making it challenging to diagnose issues or further optimize your application.
In this tutorial, we’ll learn how to leverage HTTP to improve the performance of a simple Rack application in an effort to demystify the intricacies of caching strategies and performance enhancement at the foundational level. If you want to learn more about the Rack application, feel free to read our pragmatic guide to building a Rack application from scratch.
Below is a simplified version of our application’s config.ru
file from
which we will be working with. If at any time you wish to explore on your own,
feel free to review the commit history.
require_relative "app/app"
app = Rack::Builder.new do
use Rack::Static,
root: "public",
urls: ["/css"],
run App.new
end
run app
Caching
Since our application’s style sheet is not likely to frequently change, it’s the perfect candidate for HTTP Caching. This is even confirmed by a Lighthouse Performance Audit which suggests we serve static assets with an effective caching strategy.
Since we’re using Rack::Static, we can easily resolve this by adding custom header rules for all static files served through our application.
require_relative "app/app"
app = Rack::Builder.new do
use Rack::Static,
root: "public",
urls: ["/css"],
+ header_rules: [
+ [:all, {"Cache-Control" => "public, max-age=31556952"}]
+ ]
run App.new
end
What this says is that we want to cache our style sheet for 1 year (31556952=
the number of seconds in a year). We also set this cache to public
since the
style sheet does not contain any user-specific information. This is important
because by using public
, we’re permitting the response to be cached by shared
caches, like a Content Delivery Network (CDN) or a proxy.
If we restart our server and run another performance audit, we’ll see that the violation has been resolved.
Conditional Requests
You might be thinking that we should set the Cache-Control
header on server
rendered pages too. Although we could do that, a more effective approach is to
use HTTP conditional requests. This ensures that the browser’s cache can be
used if the response body hasn’t changed since the last request.
This works by setting an ETag to identify the version of a specific resource (usually by hashing the response body) and comparing it to the If-None-Match header in the request. If the two values match, then a 304 Not Modified is returned instead of a 200 OK.
We could do this manually, but fortunately for us, the Rack library ships with Rack::ConditionalGet to do this for us. All we need to do is add it to our stack.
require_relative "app/app"
app = Rack::Builder.new do
+ use Rack::ConditionalGet
+ use Rack::ETag
use Rack::Deflater
use Rack::Static
root: "public",
urls: ["/css"],
We can verify that this worked by visiting a server-rendered page in our application. The first request should result in a 200 OK, but any subsequent request will return a 304 Not Modified (so long as the response body hasn’t changed), resulting in a smaller response size.
Before
After
It’s worth noting that even though the response size was reduced, the response time remained the same. This is because the server-side logic used to build the response body was still invoked in order to compare the headers. If we wanted to further improve performance, we could store the response in a cache store like Redis.
if (requested_etag = req.headers["If-None-Match"]) && etag_still_warm_in_redis_cache?(requested_etag)
[304, {}, []]
else
slow_uncached_action
end
We can also verify that the ETag and If-None-Match headers are set by inspecting the request.
Compression
Finally, we can leverage HTTP Compression to drastically reduce the size of response documents in an effort to improve performance. We can confirm our application is not utilizing any sort of compression by running a Lighthouse Performance Audit.
Luckily, the Rack library makes implementing this fix effortless with the Rack::Deflator middleware (which we’ve written about before). All we need to do is add it to our stack.
require_relative "app/app"
app = Rack::Builder.new do
+ use Rack::Deflater
use Rack::ConditionalGet
use Rack::ETag
use Rack::Static,
root: "public",
urls: ["/css"],
We can verify that our application is using HTTP Compression by running the performance audit again.
Wrapping up
The concepts we’ve discussed aren’t specific to Rack and serve as a reminder that understanding HTTP is vital to web application development.
Rack makes setting these headers easy thanks to its available middleware, but there’s nothing stopping you from manually setting a response header in your Rack compliant application.